Skip to content

Skip private/internal IPs when detecting the client IP from headers#4329

Open
kriscarle wants to merge 1 commit into
umami-software:devfrom
maphubs:fix/skip-private-ips-from-headers
Open

Skip private/internal IPs when detecting the client IP from headers#4329
kriscarle wants to merge 1 commit into
umami-software:devfrom
maphubs:fix/skip-private-ips-from-headers

Conversation

@kriscarle

@kriscarle kriscarle commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

When a request reaches umami through more than one proxy/load balancer, getIpAddress() could return an internal address (e.g. 10.0.0.8) instead of the real client IP, which then breaks geolocation and IP-based filtering.

The previous logic picked the first candidate header that was merely present (IP_ADDRESS_HEADERS.find(...)) and never checked whether the value was an internal/proxy address. This change iterates the candidate headers in priority order, normalizes each value, and skips private/internal addresses, falling through to the first header that carries a public client IP.

Root cause

In a multi-proxy chain (in our case client → cloud load balancer → fabio → fabio → umami), only the leftmost entry of X-Forwarded-For reliably holds the original client. Other headers carry the previous hop, not the originator:

  • X-Forwarded-For is append-only. Per MDN, "the rightmost IP address is the IP address of the most recent proxy and the leftmost IP address is the address of the originating client." So the internal proxy hops legitimately appear in the list alongside the client.

  • The Forwarded header's for= is the immediate peer. RFC 7239 defines for= as "the node making the request to the proxy" — i.e. the previous hop. Proxies such as fabio set both Forwarded: for= and X-Real-Ip from the incoming connection's RemoteAddr:

    remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
    ...
    if r.Header.Get("X-Real-Ip") == "" { r.Header.Set("X-Real-Ip", remoteIP) }
    ...
    fwd = "for=" + remoteIP + "; proto=" + proto

    By the time the request reaches such a proxy, RemoteAddr is an internal node (e.g. 10.0.0.8), so that internal IP ends up in Forwarded (always) and in X-Real-Ip (whenever an upstream hasn't already stamped the real client).

Because forwarded/x-real-ip can therefore legitimately contain an internal address, selecting the first present header without inspecting its value yields the wrong IP. This is a generic multi-proxy artifact rather than a single-proxy misconfiguration.

The fix

  • Walk IP_ADDRESS_HEADERS in order; for each, extract the candidate IP (first entry for x-forwarded-for, for= for forwarded), resolveIp() it, and skip it if it is private/internal (RFC1918, loopback, link-local, CGNAT, IPv6 unique-local — detected via ipaddr.range()).
  • Return the first public address found.
  • If every candidate is private (e.g. local/dev setups), the first one is still returned as a last resort, so existing behavior is preserved.
  • The CLIENT_IP_HEADER path now goes through the same extraction, so a custom header pointed at a multi-IP x-forwarded-for list resolves the first entry instead of the raw comma-separated string.

Header priority order is unchanged — the fix is purely additive (skip-private), so setups whose first header is already public behave exactly as before.

Tests

Added two cases to src/lib/detect.test.ts:

  • an internal x-real-ip with a public x-forwarded-for resolves to the public client IP;
  • CLIENT_IP_HEADER=x-forwarded-for with a multi-IP list extracts the first IP.

Existing getIpAddress tests continue to pass.

References


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

When a request passes through internal proxies or load balancers (e.g.
Nomad/fabio behind a cloud load balancer), a header such as `x-real-ip`
or `forwarded` can carry an internal address (10.x, 172.16-31.x, 192.168.x,
loopback, link-local, CGNAT, IPv6 ULA) while the real client IP is present
in another header like `x-forwarded-for`. Because the previous logic picked
the first header that was merely present, it could return the internal IP.

Iterate the candidate headers in priority order, normalize each value, and
skip private/internal addresses, falling through to the next header that
carries a public client IP. If every candidate is private, the first one is
still returned as a last resort so behaviour is unchanged for local setups.

Also route the `CLIENT_IP_HEADER` path through the same extraction so a
custom header pointing at a multi-IP `x-forwarded-for` list resolves the
first entry instead of the raw list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 8, 2026

Copy link
Copy Markdown

@kriscarle is attempting to deploy a commit to the Umami Software Team on Vercel.

A member of the Team first needs to authorize it.

@kriscarle kriscarle marked this pull request as ready for review June 8, 2026 20:03
@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes getIpAddress so that private/internal addresses injected by intermediate proxies (e.g. 10.x in x-real-ip or Forwarded: for=) are skipped in favour of a higher-quality public IP from another candidate header, while preserving the first private address as a last-resort fallback for all-private setups.

  • extractIp is added to normalize header values (x-forwarded-for comma-list, Forwarded for= syntax, raw values) before the private-IP check.
  • isPrivateIp is added to detect RFC1918, loopback, link-local, CGNAT, and IPv6 unique-local ranges via ipaddr.js; getIpAddress loops candidate headers in priority order and skips any that yield a private address.
  • Two new tests cover the key multi-proxy scenario and the CLIENT_IP_HEADER=x-forwarded-for extraction path.

Confidence Score: 4/5

Safe to merge with low risk; the change only affects IP resolution ordering and is backward-compatible for single-proxy deployments.

The core loop logic is correct and well-tested for the described multi-proxy scenario. Two narrow gaps exist: isPrivateIp fails silently when the Forwarded header delivers a bracketed IPv6 (e.g. [::1]), causing a loopback address to bypass the private check; and the CLIENT_IP_HEADER early-return path intentionally skips the private-IP filter but has no comment documenting this. Neither gap affects the common case.

src/lib/ip.ts — specifically the isPrivateIp interaction with extractIp for the forwarded header.

Important Files Changed

Filename Overview
src/lib/ip.ts Adds isPrivateIp and extractIp helpers; rewrites getIpAddress to loop through candidate headers and skip private/internal addresses. Core logic is sound, but isPrivateIp fails silently for bracketed IPv6 from the Forwarded header and the CLIENT_IP_HEADER early-return path does not apply the private-IP filter.
src/lib/detect.test.ts Adds two new test cases: private x-real-ip with public x-forwarded-for resolves to the public IP, and CLIENT_IP_HEADER=x-forwarded-for with a multi-IP list extracts the first IP. Both tests include proper env var cleanup.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Request arrives at getIpAddress] --> B{CLIENT_IP_HEADER set?}
    B -- Yes --> C[Get header value]
    C --> D{Value present?}
    D -- Yes --> E[extractIp: handle XFF/Forwarded/raw]
    E --> F[resolveIp]
    F --> G[Return IP - no private check]
    D -- No --> H
    B -- No --> H[Loop IP_ADDRESS_HEADERS in priority order]
    H --> I{Header value present?}
    I -- No --> J[Next header]
    J --> I
    I -- Yes --> K[extractIp]
    K --> L[resolveIp]
    L --> M{ip truthy?}
    M -- No --> J
    M -- Yes --> N{isPrivateIp?}
    N -- Yes --> O{fallback set?}
    O -- No --> P[fallback = ip]
    O -- Yes --> J
    P --> J
    N -- No --> Q[Return public IP]
    J -- exhausted --> R[Return fallback or undefined]

    style G fill:#f9c,stroke:#c66
    style Q fill:#cfc,stroke:#6c6
    style R fill:#ffc,stroke:#cc6
Loading

Reviews (1): Last reviewed commit: "Skip private/internal IPs when detecting..." | Re-trigger Greptile

Comment thread src/lib/ip.ts
Comment on lines +91 to +94
if (header === 'forwarded') {
const match = value.match(/for=(\[?[0-9a-fA-F:.]+]?)/);

return match ? match[1] : undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 isPrivateIp bypassed for bracketed IPv6 in Forwarded header

The extractIp regex /for=(\[?[0-9a-fA-F:.]+]?)/ can capture the brackets as part of the match, e.g. Forwarded: for=[::1] yields [::1] as the captured group. ipaddr.parse('[::1]') throws (brackets are not valid in the parser's notation), so isPrivateIp silently returns false, and the IPv6 loopback [::1] bypasses the private-IP guard and is returned as the client address. Stripping brackets before calling isPrivateIp (and before the resolveIp call) would close this gap — the plain form ::1 parses correctly and .range() returns 'loopback'.

Comment thread src/lib/ip.ts
Comment on lines +103 to 109
if (customHeader) {
const value = headers.get(customHeader);

if (value) {
return resolveIp(extractIp(customHeader, value));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CLIENT_IP_HEADER path skips private-IP check

When CLIENT_IP_HEADER is set to a header that contains a private address (e.g. CLIENT_IP_HEADER=x-real-ip and x-real-ip: 10.0.0.8), the code returns that private IP immediately without applying the isPrivateIp filter or falling through to other headers. This is a deliberate design choice (the user explicitly chose that header), but it means the custom-header path can still return internal addresses that break geolocation. A comment noting this intentional behavior would help future readers understand why the private-IP logic is omitted here.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant